Skip to content

feat(client): add opt-in card validation hooks#1142

Closed
piyushbag wants to merge 1 commit into
a2aproject:mainfrom
piyushbag:fix/975-card-validators
Closed

feat(client): add opt-in card validation hooks#1142
piyushbag wants to merge 1 commit into
a2aproject:mainfrom
piyushbag:fix/975-card-validators

Conversation

@piyushbag

Copy link
Copy Markdown

Summary

  • Add a2a.client.card_validators with composable CardValidator hooks and InvalidAgentCardError.
  • Wire optional validators into ClientFactory and create_client (default: no validation, backward compatible).
  • Ship built-in helpers: require_supported_interfaces, reject_non_https_urls, and reject_private_urls.
  • Add unit tests for validators and factory integration.

Problem

Untrusted cards resolved via create_from_url had no SDK hook for caller-defined validation before client creation (#975). Callers needing HTTPS-only or private-network policies had to duplicate checks.

Test plan

  • uv run ruff check on changed files
  • uv run pytest tests/client/test_card_validators.py tests/client/test_client_factory.py -q

Fixes #975

Introduce composable CardValidator hooks on ClientFactory and
create_client, plus built-in helpers for HTTPS-only and private URL
checks. Default behavior remains unchanged when no validators are set.

Fixes a2aproject#975
@piyushbag
piyushbag requested a review from a team as a code owner July 21, 2026 17:39

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces optional validation hooks for AgentCard instances before client creation, including validators to reject non-HTTPS URLs, private/loopback IP addresses, and cards without supported interfaces. The feedback recommends strengthening the private URL check to explicitly reject hostnames like localhost and .local domains, extending URL extraction to include the provider.url field, and parameterizing the tests to cover these additional private and loopback address formats.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +50 to +56
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
try:
ip = ipaddress.ip_address(host)
except ValueError:
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The _check_not_private function checks if the hostname is a private IP address, but it does not reject standard loopback or local hostnames like localhost or .local domains. Since ipaddress.ip_address raises a ValueError for non-IP hostnames, names like localhost or service.local will bypass this check. Explicitly rejecting localhost, .localhost, and .local domains prevents private network access via these hostnames.

Suggested change
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
host_lower = host.lower()
if (
host_lower == 'localhost'
or host_lower.endswith('.localhost')
or host_lower.endswith('.local')
):
raise InvalidAgentCardError(
f'URL points to a private or loopback address: {url!r}'
)
try:
ip = ipaddress.ip_address(host)
except ValueError:
return

Comment on lines +20 to +30
def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
return urls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The _iter_card_urls helper extracts URLs from the card's supported interfaces, documentation, and icon, but it misses the provider.url field. If an untrusted card specifies a malicious or private URL in provider.url, it will bypass validation. Including card.provider.url ensures all metadata URLs are validated. Note that we only check and append card.provider.url if it is present, to handle cases where the URL might not be set.

Suggested change
def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
return urls
def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
if card.provider.url:
urls.append(card.provider.url)
return urls
References
  1. When displaying provider information, ensure that the URL is only shown if it is present, even if the specification indicates it should be a required field, to handle cases where the current implementation might allow creation without the URL set.
  2. If a field in a data model (e.g., ServerCallContext.user) is non-optional, avoid adding redundant checks for its existence and rely on the data model's contract.

Comment on lines +69 to +80
def test_reject_private_urls_rejects_loopback():
card = _valid_card(
supported_interfaces=[
AgentInterface(
protocol_binding=TransportProtocol.JSONRPC,
url='https://127.0.0.1',
)
]
)

with pytest.raises(InvalidAgentCardError, match='private or loopback'):
reject_private_urls(card)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Parameterize the private URL rejection test to cover localhost, .localhost, .local domains, and IPv6 loopback addresses ([::1]) to ensure comprehensive validation coverage.

@pytest.mark.parametrize(
    'url',
    [
        'https://127.0.0.1',
        'https://localhost',
        'https://test.localhost',
        'https://my-service.local',
        'https://[::1]',
    ],
)
def test_reject_private_urls_rejects_loopback(url):
    card = _valid_card(
        supported_interfaces=[
            AgentInterface(
                protocol_binding=TransportProtocol.JSONRPC,
                url=url,
            )
        ]
    )

    with pytest.raises(InvalidAgentCardError, match='private or loopback'):
        reject_private_urls(card)

@github-actions

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/client/client_factory.py 88.67% 88.82% 🟢 +0.15%
src/a2a/client/card_validators.py (new) 61.29%
Total 92.97% 92.75% 🔴 -0.22%

Generated by coverage-comment.yml

@ishymko

ishymko commented Jul 22, 2026

Copy link
Copy Markdown
Member

Hi @piyushbag, thank you for your PR, please note that the issue is labeled with maintainers-only, we're not accepting external contributions for it at this point.

@ishymko ishymko closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Agent card validation hook

2 participants